LOADING...

加载过慢请开启缓存(浏览器默认开启)

loading

BpNet

2021/8/25

BP神经网络

隐藏层层数和神经元个数的确定

输入、输出的神经元个数很好确定,因为输入、输出对应着你的实际需求,但是隐藏层就比较难确定了。一般情况下,各个隐藏层的神经元个数相同即可。层数不宜过多,神经元添加过多的时候可以考虑增加层数。

有些可以参考的经验公式,例如有大神分享自己的经验公式

Nh=Nsα(Ni+No)N_h = \frac{N_s}{\alpha*(N_i+N_o)}

其中NiN_iNoN_oNsN_s分别代表输入层、输出层的神经元个数以及训练样本数。

α\alpha是可调值,一般在2~10。

还有可参考的一些依据,例如Nh=23(Ni+No)N_h=\frac{2}{3}(N_i+N_o)Nh<2NiN_h<2*N_i等等

目前代码里实现的是一个隐藏层,神经元个数可调。

reference:https://zhuanlan.zhihu.com/p/47519999

reference:https://zhuanlan.zhihu.com/p/100419971

数据准备

数据的存储可以使用csv文件,然后使用pandas库,读取和写入都十分方便。

注意如果单纯使用到数据的话,要看情况使得header=None。

还有就是注意维度为1的,要squeeze一下。

因为维度为1是长度为1的列表,而不是单纯一个数据。

对于输出分类,可以转换成二进制,增加输出层神经元的个数。

转换也十分方便,可以用numpy的identity生成的方阵访问下标轻松获取。

代码如下:

# 数据准备 (x,y) -> (examples_num * input_num, examples_num * output_num)
train_data = pd.read_csv('./data/trainData.csv', header=None)
train_data = shuffle(train_data)  # 打乱数据
x_train = train_data.iloc[:, 0:4].values
y_train = train_data.iloc[:, 4:5].values
y_train = np.squeeze(y_train)
y_train = np.identity(3)[y_train]  # 将标签转变为二进制

激活函数

隐藏层和输出层之前都需要激活,激活函数起到非线性的作用。

常见的有sigmoidtanh

两者比较大的差别就是前者范围是(0,1),后者是(-1,1)。

sigmoid的表达式是f(x)=11+e(x)f(x) =\frac{1}{1+e^(-x)},求导是f=f(1f)\partial{f}=f(1-f)

tanh的表达式是f(x)=exexex+exf(x) = \frac{e^x-e^{-x}}{e^x+e^{-x}}。,求导是f=1f2\partial{f}=1-f^2

代码实现如下:

# Activation Function ("sigmoid", "tanh")
self._activationToHideName = "sigmoid"
if self._activationToHideName == "sigmoid":
    self._activationToHide = lambda i: 1 / (1 + np.exp(-i))
    self._pd_activationToHide = lambda i: i * (1 - i)  # 偏导
elif self._activationToHideName == "tanh":
    self._activationToHide = lambda i: (np.exp(i) - np.exp(-i)) / (np.exp(i) + np.exp(-i))
    self._pd_activationToHide = lambda i: 1 - i * i  # 偏导

损失函数

损失函数是来衡量网络输出与实际值的差距的。常见有均方误差(MSE)交叉熵(Cross-entropy)

假设输出是oo,实际值是yy

则MSE的表达式是 cost=12(yo)2cost =\frac{1}{2}(y-o)^2。偏导数是cost=(yo)\partial{cost}=-(y-o)

交叉熵的表达式是 cost=(log(o)y+log(1o)(1y))cost =-(log(o)*y+log(1-o)*(1-y))。偏导数是cost=yoo(1o)\partial{cost}=-\frac{y-o}{o(1-o)}

结合前面激活函数可以发现,如果使用交叉熵为损失函数和用sigmoid为激活函数,

在反向传播时偏导是要相乘的,所以可以消掉。这也是很多人把这两个结合起来一起用的原因吧。

一般选择sigmoid为输出层激活函数时,不会选择MSE,会导致权重更新过慢。

而用tanh为输出层激活函数时,不能用交叉熵,因为tanh输出包含负数。

代码实现如下:

# Loss Function ("MSE", "Cross-entropy")
self._loss_functionName = "Cross-entropy"
if self._loss_functionName == "MSE":
    self._loss_function = lambda x, y: 1 / 2 * (y - x) * (y - x)
    self._pd_loss_function = lambda x, y: -(y - x)
elif self._loss_functionName == "Cross-entropy":
    self._loss_function = lambda x, y: -(np.log(x) * y + (1 - y) * np.log(1 - x))
    self._pd_loss_function = lambda x, y: -(y - x) / (x * (1 - x))

另外代码中加入了L2正则化

所以损失函数还要加上λ2W2n\frac{\lambda}{2}\frac{\sum{W^2}}{n}

def compute_cost(self, output):
    return np.sum(self._loss_function(output, self._y_train)) / self._sample_num + \
   self._reg / 2 * (np.sum(np.square(self._w1)) + np.sum(np.square(self._w2))) / self._sample_num  # L2正则化

正向传播

正向传播分为输入层到隐藏层以及隐藏层到输出层

层与层之间也是分为线性计算激活函数两部分。

首先是输入层到隐藏层,

假设输入是ii,权值是w1w_1,偏置是b1b_1,激活函数是f1(x)f_1(x),隐藏层是hh

则从输入层到隐藏层可以表示为 h=f1(w1i+b1)h=f_1(w_1 * i +b_1)

隐藏层到输出层同理

权值是w2w_2,偏置是b2b_2,激活函数是f2(x)f_2(x),输出层是oo

o=f2(w2h+b2)o=f_2(w_2 * h + b_2)

这样就实现了整个正向传播的过程

上述的权值和输入是否需要转置是根据自己确定

值得一提的是,当输入是n个样本时,w1iw_1 * ib1b_1是不同维度的。

前者是隐藏层神经元个数*样本数,后者是 隐藏层神经元个数*1

虽然说在python代码里是可以直接相加的,但是还是要理解清楚各参数维度。

因为一旦样本数为n时,后面很多地方都需要理解分析是否求了所有样本和而需要除以n。

代码如下:

def forward(self, x):
    """前向传播"""
    a1 = np.dot(x, self._w1) + self._b1  # 维度不同的矩阵相加
    hide = self._activationToHide(a1)
    a2 = np.dot(hide, self._w2) + self._b2
    output = self._activationToOutput(a2)
    return hide, output

反向传播

反向传播其实就是求总损失对各个参数的偏导,然后用于更新参数。

求偏导运用到链式求导法则,这个需要自己推过一遍才更好理解。

注意求输入层到隐藏层时的偏导来自于输出层的很多部分,要逐一求后叠加。

代码如下:

def backward(self, hide, output):
    """反向传播"""
    # output -> hide
    d_activate2out = self._pd_loss_function(output, self._y_train) * self._pd_activationToOutput(output)
    dw2 = np.dot(hide.T, d_activate2out) / self._sample_num
    db2 = np.sum(d_activate2out, axis=0, keepdims=True) / self._sample_num

    # hide -> input
    d_activate2hide = np.dot(d_activate2out, self._w2.T) * self._pd_activationToHide(hide)
    dw1 = np.dot(self._x_train.T, d_activate2hide) / self._sample_num
    db1 = np.sum(d_activate2hide, axis=0, keepdims=True) / self._sample_num

reference:https://www.cnblogs.com/charlotte77/p/5629865.html

更新参数

更新参数用到的就是反向传播求出来的偏导

假设更新的是w1w_1,则就是求totalw1\frac{\partial{total}}{\partial{w_1}}

然后更新参数w1=w1lrtotalw1w_1 = w_1 - lr *\frac{\partial{total}}{\partial{w_1}}。其中lrlr是学习率。

学习率这里值得一提一下,在训练初期需要较高的学习率,而在训练后期则需要较低的学习率。

所以学习率应该是一个和迭代次数有关的单调递减函数。

代码如下:

self._base_lr = 0.01
self._gamma = 0.001

self._lr = self._base_lr * pow(1 + self._gamma * i, 0.75)  # 根据迭代次数更新学习率

另外代码中加入了L2正则化,所以有

dw2 += self._reg / self._sample_num * self._w2
dw1 += self._reg / self._sample_num * self._w1

训练

训练其实就是把上述的过程结合起来,

根据迭代次数不断正向传播、反向传播、更新参数、打印损失函数值。

代码如下:

def train(self):
    """训练"""
    for i in range(self._iter_num):
        hide, output = self.forward(self._x_train)
        update_data = self.backward(hide, output)

        self._lr = self._base_lr * pow(1 + self._gamma * i, 0.75)  # 根据迭代次数更新学习率
        self.update_parameter(*update_data)

        cost = self.compute_cost(output)
        if i % 1000 == 0:
            print(f"\033[31m 迭代次数{i},损失函数值{cost}\033[0m")

测试

最后是通过训练出来的参数,对测试样本进行正向传播,看看效果。

这里采用取网络输出值和实际值最大值的下标,

通过比较就可以知道相似度了

代码如下:

def test(self):
    _, test_out = self.forward(self._x_test)

    # 对比网络输出和实际值 取最大值的下标进行对比
    test_out = np.argmax(test_out, axis=1)
    y_test = np.argmax(self._y_test, axis=1)
    print("预测准确率:{:.2f}%".format(np.sum(test_out == y_test) / len(test_out) * 100))

图形界面显示

画图显示可以更直观看出数值变化,所以我就自己写了一个Display的类,输入数值即可看到随时间的变化。

有动态时间轴显示和静态时间轴显示两种方法。

前者是时间轴会不断向左移动,旧的数据会逐步往左消失掉,避免数据量太大,导致显示的比例尺不对。

后者是时间轴不会移动,旧的数据不会消失掉,用于最后观察整个过程数据的变化。

代码如下:

import matplotlib.pyplot as plt
from collections import deque
import time


class Display:
    def __init__(self):
        self._fig, self._ax = plt.subplots()
        self._start_time = time.time()

        # 随着时间轴的推移,负轴部分的数据应该舍弃 而负的数据在头部 为了提高效率选用单向队列FIFO
        self._time = deque()
        self._y = deque()
        
        self._threshold = -1.0  # 较小数舍弃阈值 影响显示画面最左侧
        self._timeline_left_parameter = 0.2  # 时间轴左移速度 影响画面往右更新

    def dynamic_timeline_show(self, data):
        # plt.ion()

        # 要显示的数据
        self._time.append(time.time() - self._start_time)
        self._y.append(data)

        # 显示前先清空上一帧
        self._ax.cla()

        # 动态设置x范围后画出
        self._ax.set_xlim(0, self._time[-1] + 10)
        self._ax.plot(self._time, self._y, c="b")

        # 时间轴左移
        for i in range(len(self._time)):
            self._time[i] -= self._timeline_left_parameter

        # 较小数舍弃
        if self._time[0] < self._threshold:
            self._time.popleft()
            self._y.popleft()

        # 延时
        plt.pause(0.1)

        # plt.ioff()

    def stationary_timeline_show(self, data):
        # plt.ion()
        
        self._time.append(time.time() - self._start_time)
        self._y.append(data)
        self._ax.plot(self._time, self._y, c="b")

        # 延时
        plt.pause(0.1)

学习感想

神经网络的学习,公式的推导还是需要自己去手写去理解,自己推导过一遍才能理解更深。

刚开始学的时候可以把每一项都写出来,也便于矩阵相乘理解。

把正向传播和反向传播都推导过一遍,理解各个层的维度变化。

然后增加到n组样本,发现其实就是改个数字,有些地方要注意除以样本数而已。

开学前的最后一篇学习笔记也写完了√